在 Day 04 認識了 AwesomeAssertions 的基礎應用後,今天我們將學習進階技巧與複雜情境應用。透過複雜物件比對、自訂 Assertions 擴展,以及動態欄位處理技巧,提升測試 Assertions 的實用性。
關於 AwesomeAssertions:
AwesomeAssertions 是 FluentAssertions 的社群分支版本,使用 Apache 2.0 授權。本章節使用 AwesomeAssertions 9.1.0 版本,該版本的 API 與 FluentAssertions 相容度很高,但有以下主要差異:
FluentAssertions
改為 AwesomeAssertions
EquivalencyAssertionOptions
改為 EquivalencyOptions
SelectedMemberPath
改為 Path
相關連結:
本章節的所有測試範例都需要以下 using 指令:
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Linq.Expressions;
using System.Threading;
using System.Threading.Tasks;
using AwesomeAssertions;
using Xunit;
using Xunit.Abstractions;
注意:所有測試類別都使用 ITestOutputHelper
來輸出測試資訊,這是 xUnit 推薦的最佳實踐,應避免使用 Console.WriteLine()
。
public class AdvancedObjectGraphTests
{
[Fact]
public void ObjectGraph_循環引用處理_應正常運作()
{
// 處理循環參考的物件比較
var parent = new TreeNode { Value = "Root" };
var child1 = new TreeNode { Value = "Child1", Parent = parent };
var child2 = new TreeNode { Value = "Child2", Parent = parent };
parent.Children = new[] { child1, child2 };
var actualTree = treeService.GetTree("Root");
// 使用循環參考處理
actualTree.Should().BeEquivalentTo(parent, options =>
options.IgnoringCyclicReferences()
.WithMaxRecursionDepth(10)
);
}
[Fact]
public void ObjectGraph_效能最佳化比較_應正常運作()
{
var largeObjectGraph = CreateLargeObjectGraph();
var actualGraph = service.ProcessLargeGraph(largeObjectGraph);
// 效能最佳化的比較策略
actualGraph.Should().BeEquivalentTo(largeObjectGraph, options =>
options.WithStrictOrdering()
.WithTracing() // 啟用詳細追蹤,便於除錯
.Including(x => x.CriticalProperties) // 只比較關鍵屬性
);
}
}
public class AdvancedAsyncAssertionTests
{
[Fact]
public async Task AsyncAssertion_執行時間_應正常運作()
{
var service = new SlowService();
// 執行時間Assertions
Func<Task> asyncAction = () => service.ProcessAsync();
await asyncAction.Should().CompleteWithinAsync(TimeSpan.FromSeconds(5));
}
[Fact]
public async Task AsyncAssertion_CancellationToken_應正常運作()
{
var service = new CancellableService();
var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(100));
// 取消令牌Assertions
Func<Task> asyncAction = () => service.LongRunningOperationAsync(cts.Token);
await asyncAction.Should().ThrowAsync<OperationCanceledException>();
}
// 並行 (Concurrency)
// 同一段時間內有多個工作在「進行中」,不一定同時執行,可能交替進行。重點是任務可以同時啟動,但不保證同時執行。
[Fact]
public async Task AsyncAssertion_並行處理_應正常運作()
{
var service = new ParallelService();
var tasks = Enumerable.Range(1, 10)
.Select(i => service.ProcessItemAsync(i))
.ToArray();
// 並行任務Assertions
await Task.WhenAll(tasks);
tasks.Should().AllSatisfy(task =>
{
task.Should().BeCompletedSuccessfully();
task.Result.Should().NotBeNull();
});
}
}
public class AdvancedExceptionAssertionTests
{
[Fact]
public void Exception_巢狀例外_應拋出例外DatabaseConnectionException與ArgumentException()
{
var databaseService = new DatabaseService();
// 巢狀例外 Assertions
Action action = () => databaseService.Connect("invalid-connection-string");
action.Should().Throw<DatabaseConnectionException>()
.WithInnerException<ArgumentException>()
.WithMessage("*connection string*")
.And.InnerException.Should().NotBeNull();
}
[Fact]
public void Exception_聚合例外_應拋出例外AggregateException與ValidationException()
{
var batchService = new BatchProcessingService();
// 聚合例外Assertions
Action action = () => batchService.ProcessBatch(invalidItems);
action.Should().Throw<AggregateException>()
.Which.InnerExceptions.Should().HaveCount(3)
.And.AllBeOfType<ValidationException>();
}
}
// 為電商專案建立的自訂Assertions
public static class ECommerceAssertions
{
public static AndConstraint<ObjectAssertions> BeValidProduct(this ObjectAssertions assertions)
{
var product = assertions.Subject as Product;
product.Should().NotBeNull("預期為 Product 物件,但找到 {0}", assertions.Subject?.GetType());
product!.Id.Should().BeGreaterThan(0, "預期 Product.Id 大於 0,但找到 {0}", product.Id);
product.Name.Should().NotBeNullOrEmpty("預期 Product.Name 不為 null 或空值");
product.Price.Should().BeGreaterThan(0, "預期 Product.Price 大於 0,但找到 {0}", product.Price);
return new AndConstraint<ObjectAssertions>(assertions);
}
public static AndConstraint<ObjectAssertions> BeValidOrder(this ObjectAssertions assertions)
{
var order = assertions.Subject as Order;
order.Should().NotBeNull("預期為 Order 物件");
order!.Items.Should().NotBeNullOrEmpty("預期 Order 至少包含一個項目");
order.TotalAmount.Should().BeGreaterThan(0, "預期 Order.TotalAmount 大於 0");
return new AndConstraint<ObjectAssertions>(assertions);
}
}
// 使用自訂Assertions
public class ProductServiceTests
{
[Fact]
public void CreateProduct_有效資料_應該回傳有效產品()
{
var product = productService.Create("Laptop", 999.99m);
// 使用領域特定Assertions
product.Should().BeValidProduct();
product.Name.Should().Be("Laptop");
}
[Fact]
public void ProcessOrder_有效項目_應該回傳有效訂單()
{
var items = new[]
{
new OrderItem { ProductId = 1, Quantity = 2 },
new OrderItem { ProductId = 2, Quantity = 1 }
};
var order = orderService.Process(items);
// 使用領域特定Assertions
order.Should().BeValidOrder();
order.Items.Should().HaveCount(2);
}
}
public static class ConditionalAssertions
{
public static ConditionalAssertion<T> When<T>(this T subject, bool condition)
{
return new ConditionalAssertion<T>(subject, condition);
}
}
public class ConditionalAssertion<T>
{
private readonly T _subject;
private readonly bool _condition;
public ConditionalAssertion(T subject, bool condition)
{
_subject = subject;
_condition = condition;
}
public AndConstraint<ObjectAssertions> Then(Action<T> assertion)
{
if (_condition)
{
assertion(_subject);
}
return new AndConstraint<ObjectAssertions>(_subject.Should());
}
}
// 使用條件式Assertions
public class ConditionalAssertionTests
{
[Theory]
[InlineData(true, "admin")]
[InlineData(false, "user")]
public void ProcessUser_依據角色_應有正確權限(bool isAdmin, string expectedRole)
{
var user = userService.ProcessUser(isAdmin);
user.Should().NotBeNull();
user.When(isAdmin)
.Then(u => u.Role.Should().Be("admin"))
.And.When(isAdmin)
.Then(u => u.Permissions.Should().Contain("DELETE"));
user.When(!isAdmin)
.Then(u => u.Role.Should().Be("user"))
.And.When(!isAdmin)
.Then(u => u.Permissions.Should().NotContain("DELETE"));
}
}
public class PerformanceOptimizedAssertions
{
[Fact]
public void LargeCollection_大型集合Assertions_應高效率執行()
{
// 模擬大量資料
var largeDataset = Enumerable.Range(1, 100000)
.Select(i => new DataRecord { Id = i, Value = $"Record_{i}" })
.ToList();
var processed = dataProcessor.ProcessLargeDataset(largeDataset);
// 優化:使用高效率的Assertions策略
processed.Should().HaveCount(largeDataset.Count);
// 抽樣驗證而非全量驗證
var sampleSize = Math.Min(1000, processed.Count / 10);
var sampleIndices = Enumerable.Range(0, sampleSize)
.Select(i => Random.Shared.Next(processed.Count))
.Distinct()
.ToList();
foreach (var index in sampleIndices)
{
processed[index].Should().NotBeNull();
processed[index].Id.Should().BeGreaterThan(0);
}
// 統計驗證
processed.Count(r => r.IsProcessed).Should().Be(processed.Count);
}
[Fact]
public void ComplexObject_複雜物件比較_應使用選擇性比較()
{
var complexObject = complexService.GenerateComplexObject();
var expectedTemplate = new ComplexObject(); // 預期模板
// 選擇性比較,避免不必要的深度比較
complexObject.Should().BeEquivalentTo(expectedTemplate, options =>
options.Including(x => x.ImportantProperty1)
.Including(x => x.ImportantProperty2)
.Including(x => x.CriticalData)
.Excluding(x => x.Timestamp) // 排除時間戳記
.Excluding(x => x.GeneratedId) // 排除自動生成欄位
);
}
}
public class DynamicFieldExclusionTests
{
[Fact]
public void EntityComparison_排除時間戳記_應正常運作()
{
var originalEntity = new UserEntity
{
Id = 1,
Name = "John Doe",
Email = "john@example.com",
CreatedAt = DateTime.Now.AddDays(-1),
UpdatedAt = DateTime.Now.AddDays(-1),
Version = 1
};
var updatedEntity = entityService.UpdateUser(1, new UpdateUserRequest
{
Name = "John Doe",
Email = "john@example.com"
});
// 排除自動更新的時間戳記和版本欄位
updatedEntity.Should().BeEquivalentTo(originalEntity, options =>
options.Excluding(e => e.UpdatedAt) // 自動更新的時間
.Excluding(e => e.Version) // 樂觀鎖版本號
.Excluding(e => e.LastModifiedBy) // 修改者記錄
);
// 單獨驗證動態欄位
updatedEntity.UpdatedAt.Should().BeAfter(originalEntity.UpdatedAt);
updatedEntity.Version.Should().Be(originalEntity.Version + 1);
}
[Fact]
public void EntityComparison_使用屬性表達式_應正常運作()
{
var user1 = userService.CreateUser("test@example.com");
var user2 = userService.GetUser(user1.Id);
// 使用屬性表達式排除欄位
user2.Should().BeEquivalentTo(user1, options =>
options.Excluding(u => u.CreatedAt)
.Excluding(u => u.UpdatedAt)
.Excluding(u => u.RowVersion)
);
}
}
public class NestedObjectExclusionTests
{
[Fact]
public void ComplexEntity_排除巢狀時間戳記_應正常運作()
{
var order = new Order
{
Id = 1,
CustomerName = "John Doe",
CreatedAt = DateTime.Now,
Items = new[]
{
new OrderItem
{
Id = 1,
ProductName = "Laptop",
AddedAt = DateTime.Now,
ModifiedAt = DateTime.Now
}
},
AuditInfo = new AuditInfo
{
CreatedBy = "system",
CreatedAt = DateTime.Now,
ModifiedBy = "system",
ModifiedAt = DateTime.Now
}
};
var retrievedOrder = orderService.GetOrder(1);
// 排除所有時間戳記欄位(包含巢狀物件)
retrievedOrder.Should().BeEquivalentTo(order, options =>
options.Excluding(o => o.CreatedAt)
.Excluding(o => o.Items[0].AddedAt)
.Excluding(o => o.Items[0].ModifiedAt)
.Excluding(o => o.AuditInfo.CreatedAt)
.Excluding(o => o.AuditInfo.ModifiedAt)
);
}
[Fact]
public void ComplexEntity_使用萬用字元排除_應正常運作()
{
var entity = complexService.ProcessEntity();
var expectedEntity = CreateExpectedEntity();
// 使用萬用字元排除所有時間相關欄位
entity.Should().BeEquivalentTo(expectedEntity, options =>
options.Excluding(ctx => ctx.Path.EndsWith("At"))
.Excluding(ctx => ctx.Path.EndsWith("Time"))
.Excluding(ctx => ctx.Path.Contains("Timestamp"))
);
}
}
public static class SmartExclusionExtensions
{
public static EquivalencyOptions<T> ExcludingAutoGeneratedFields<T>(
this EquivalencyOptions<T> options)
{
return options
.Excluding(ctx => ctx.Path.EndsWith("Id") &&
ctx.SelectedMemberInfo.Name.StartsWith("Generated"))
.Excluding(ctx => ctx.Path.EndsWith("At"))
.Excluding(ctx => ctx.Path.EndsWith("Time"))
.Excluding(ctx => ctx.Path.Contains("Version"))
.Excluding(ctx => ctx.Path.Contains("RowVersion"))
.Excluding(ctx => ctx.Path.Contains("Timestamp"));
}
public static EquivalencyOptions<T> ExcludingAuditFields<T>(
this EquivalencyOptions<T> options)
{
return options
.Excluding(ctx => ctx.Path.Contains("CreatedBy"))
.Excluding(ctx => ctx.Path.Contains("CreatedAt"))
.Excluding(ctx => ctx.Path.Contains("ModifiedBy"))
.Excluding(ctx => ctx.Path.Contains("ModifiedAt"))
.Excluding(ctx => ctx.Path.Contains("LastModified"));
}
}
public class SmartExclusionTests
{
[Fact]
public void EntityComparison_使用智慧排除_應正常運作()
{
var user = userService.CreateUser("test@example.com");
var retrievedUser = userService.GetUser(user.Id);
// 使用智慧排除擴充方法
retrievedUser.Should().BeEquivalentTo(user, options =>
options.ExcludingAutoGeneratedFields()
.ExcludingAuditFields()
);
}
[Fact]
public void ComplexEntity_組合排除策略_應正常運作()
{
var order = orderService.CreateOrder(orderRequest);
var processedOrder = orderService.ProcessOrder(order.Id);
processedOrder.Should().BeEquivalentTo(order, options =>
options.ExcludingAutoGeneratedFields()
.ExcludingAuditFields()
.Excluding(o => o.Status) // 額外排除狀態欄位
.Excluding(o => o.ProcessedAt)
);
}
}
// 大量資料比對的效能優化策略
public static class PerformanceOptimizedAssertions
{
// 選擇性屬性比較,避免全物件掃描
public static void AssertLargeDataSet<T>(IEnumerable<T> actual, IEnumerable<T> expected)
{
actual.Should().HaveCount(expected.Count());
// 分批處理,避免記憶體壓力
var actualBatches = actual.Chunk(1000);
var expectedBatches = expected.Chunk(1000);
actualBatches.Zip(expectedBatches, (a, e) => new { Actual = a, Expected = e })
.AsParallel()
.ForAll(batch =>
{
batch.Actual.Should().BeEquivalentTo(batch.Expected);
});
}
// 關鍵屬性快速比對
public static void AssertKeyPropertiesOnly<T>(T actual, T expected, params Expression<Func<T, object>>[] keySelectors)
{
foreach (var selector in keySelectors)
{
var actualValue = selector.Compile()(actual);
var expectedValue = selector.Compile()(expected);
actualValue.Should().Be(expectedValue, $"關鍵屬性 {selector} 應該相符");
}
}
}
// 實際應用範例
public class PerformanceOptimizedTests
{
private readonly ITestOutputHelper _output;
public PerformanceOptimizedTests(ITestOutputHelper output)
{
_output = output;
}
[Fact]
public void LargeDataSet_大量資料驗證_應使用效能優化策略()
{
// Arrange
// 建立大量測試資料
var expectedUsers = Enumerable.Range(1, 50000)
.Select(i => new User { Id = i, Name = $"User{i}", Email = $"user{i}@example.com" })
.ToList();
// Act
var actualUsers = userService.ProcessLargeUserBatch(expectedUsers);
// Assert
// 使用效能優化的 Assertions 方法
PerformanceOptimizedAssertions.AssertLargeDataSet(actualUsers, expectedUsers);
// 驗證:大量資料處理應該在合理時間內完成
_output.WriteLine($"成功驗證 {expectedUsers.Count} 筆使用者資料");
}
[Fact]
public void EntityComparison_關鍵屬性_應快速比對()
{
// Arrange
var expectedOrder = new Order
{
Id = 1,
CustomerName = "John Doe",
TotalAmount = 999.99m,
Status = "Processing",
CreatedAt = DateTime.Now, // 這個會變動
UpdatedAt = DateTime.Now // 這個會變動
};
// Act
var actualOrder = orderService.GetOrder(1);
// Assert
// 只比較關鍵屬性,忽略會變動的時間戳記
PerformanceOptimizedAssertions.AssertKeyPropertiesOnly(
actualOrder,
expectedOrder,
o => o.Id,
o => o.CustomerName,
o => o.TotalAmount,
o => o.Status
);
// 這種方式比完整的 BeEquivalentTo 快很多
_output.WriteLine("快速驗證訂單關鍵屬性完成");
}
[Fact]
public void ComplexScenario_結合多種優化策略_應高效率執行()
{
var largeOrderBatch = GenerateLargeOrderBatch(10000);
var processedOrders = orderService.ProcessOrderBatch(largeOrderBatch);
var stopwatch = Stopwatch.StartNew();
// 先用快速方法檢查數量
processedOrders.Should().HaveCount(largeOrderBatch.Count);
// 抽樣詳細驗證(只驗證前100筆)
var sampleOrders = processedOrders.Take(100).ToList();
var expectedSample = largeOrderBatch.Take(100).ToList();
sampleOrders.Should().BeEquivalentTo(expectedSample, options =>
options.Excluding(o => o.ProcessedAt)
.Excluding(o => o.UpdatedAt));
// 對剩餘的只做關鍵屬性驗證
var remainingOrders = processedOrders.Skip(100).ToList();
var expectedRemaining = largeOrderBatch.Skip(100).ToList();
for (int i = 0; i < remainingOrders.Count; i++)
{
PerformanceOptimizedAssertions.AssertKeyPropertiesOnly(
remainingOrders[i],
expectedRemaining[i],
o => o.Id,
o => o.TotalAmount,
o => o.Status
);
}
stopwatch.Stop();
// 效能驗證
stopwatch.ElapsedMilliseconds.Should().BeLessThan(5000, "因為混合驗證策略應該在 5 秒內完成");
_output.WriteLine($"混合策略驗證 {processedOrders.Count} 筆訂單,耗時 {stopwatch.ElapsedMilliseconds}ms");
}
private List<Order> GenerateLargeOrderBatch(int count)
{
return Enumerable.Range(1, count)
.Select(i => new Order
{
Id = i,
CustomerName = $"Customer{i}",
TotalAmount = i * 10.5m,
Status = "Pending"
})
.ToList();
}
}
// 建立團隊測試指南
public class TeamTestingGuidelines
{
// 標準 1:使用有意義的測試名稱
[Fact]
public void CreateUser_有效電子郵件_應回傳啟用的使用者()
{
// 遵循:被測試方法_測試情境_預期行為
}
// 標準 2:使用流暢的Assertions風格
[Fact]
public void ProcessOrder_多個項目_應計算正確的總金額()
{
var order = orderService.ProcessOrder(validItems);
// 推薦:鏈式Assertions,邏輯清晰
order.Should().NotBeNull()
.And.BeOfType<Order>();
order.TotalAmount.Should().BeGreaterThan(0);
order.Items.Should().HaveCount(validItems.Count);
}
// 標準 3:適當使用自訂Assertions
[Fact]
public void RegisterUser_完整個人資料_應建立有效使用者()
{
var user = userService.Register(completeProfile);
// 使用領域特定Assertions
user.ShouldBeValidUser();
user.Profile.ShouldBeComplete();
}
}
// 提供更有意義的錯誤訊息
public class ErrorMessageOptimizationTests
{
[Fact]
public void ProcessPayment_無效金額_應提供詳細錯誤訊息()
{
var payment = new PaymentRequest { Amount = -100 };
var result = paymentService.ProcessPayment(payment);
// 提供詳細的錯誤上下文
result.IsSuccess.Should().BeFalse("because negative payment amounts are not allowed");
result.ErrorMessage.Should().Contain("amount",
"because error message should specify the problematic field");
result.ErrorCode.Should().Be("INVALID_AMOUNT",
"because specific error codes help with troubleshooting");
}
[Fact]
public void ComplexObjectValidation_詳細失敗分析_應提供清楚的失敗原因()
{
var order = orderService.CreateOrder(invalidOrderData);
// 組合多個條件並提供清楚的失敗原因
order.Should().NotBeNull("because order creation should not return null")
.And.Subject.As<Order>().Items.Should().NotBeEmpty(
"because an order must contain at least one item")
.And.OnlyContain(item => item.Price > 0,
"because all items must have positive prices");
}
}
// 建立測試品質指標
public class TestQualityMetrics
{
[Fact]
public void TestAssertion_應提供可操作的資訊()
{
var userRegistration = userService.RegisterUser(testUserData);
// 分層驗證,提供階段性失敗資訊
using (new AssertionScope())
{
userRegistration.Should().NotBeNull("User registration should not fail completely");
userRegistration.UserId.Should().BeGreaterThan(0, "User should be assigned a valid ID");
userRegistration.Email.Should().Be(testUserData.Email, "Email should match input");
userRegistration.IsEmailVerified.Should().BeFalse("Email should require verification initially");
}
}
[Fact]
public void PerformanceAssertion_應包含時間上下文()
{
var stopwatch = Stopwatch.StartNew();
var result = heavyComputationService.ProcessLargeDataSet(largeDataSet);
stopwatch.Stop();
// 效能Assertions包含具體數據
result.Should().NotBeNull("Processing should complete successfully");
stopwatch.Elapsed.Should().BeLessThan(TimeSpan.FromSeconds(5),
$"Processing {largeDataSet.Count} items should complete within 5 seconds, " +
$"but took {stopwatch.Elapsed.TotalSeconds:F2} seconds");
}
}
進階 Assertions 技巧掌握:
自訂 Assertions 設計能力:
複雜情境處理策略:
動態欄位處理技巧:
明天我們將說明 Code Coverage 程式碼涵蓋範圍實戰指南,內容包括:
今天介紹了 AwesomeAssertions 的進階應用,工具的價值不在於它有多複雜,而在於它能讓複雜的事情變簡單。
身為一個老派工程師,我的實際感想:
測試 Assertions 需要平衡考量:
在這個 AI 輔助的時代,這些基礎能力仍然重要。因為 AI 可以幫我們生成測試程式碼,但無法替我們決定什麼是值得測試的、什麼是合理的 Assertions 策略。
明天我們來瞭解與認識什麼是 Code Coverage 以及要寫多少的測試才足夠。
範例程式碼:
這是「重啟挑戰:老派軟體工程師的測試修練」系列的第五天。明天我們將介紹 Code Coverage 的實務應用!